#!/bin/bash

#  git-graph - print pretty git commit logs
#
#  Copyright 2016-2018,2020,2023-2024 bill-auger <https://github.com/bill-auger>
#
#  git-graph is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  git-graph is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License version 3
#  along with git-graph.  If not, see <http://www.gnu.org/licenses/>.

# USAGE: git-graph [ -a | -n <N_COMMITS> | -u ] [ branch_name | tag_name | commit_id | file ]


## configuration ##

# PUB_BRANCH eg: 'master', 'upstream/stable-1.0' - is remote tracking branch, if empty
PUB_BRANCH=


## constants ##

readonly DEF_HASH_LEN=7 # git may extend to the minimum unique prefix
readonly JOIN_CHAR='~'
readonly HRULE_CHAR='-'
readonly GRAPH_REGEX="(.+)"
readonly ID_REGEX="(.+)"
readonly DATE_REGEX="(.+)"
readonly AUTHOR_REGEX="(.+)"
readonly SIG_REGEX="\[(([^\(\)]*) + *\(?.*\)? *<.*>|)\]"
readonly STAT_REGEX="\[(.)\]"
readonly MSG_REGEX="(.*)"
readonly REFS_REGEX="\((.*)\)"
readonly GIT_LOG_FMT="%%h${JOIN_CHAR}%%ad${JOIN_CHAR}%%an${JOIN_CHAR}[%%GS]${JOIN_CHAR}[%%G?]${JOIN_CHAR}%%s${JOIN_CHAR}(%%D)"
readonly GIT_LOG_CMD_FMT="git log --graph --date=short -n %d --pretty=format:$GIT_LOG_FMT --abbrev=${DEF_HASH_LEN}"
readonly LOG_REGEX="^${GRAPH_REGEX} ${ID_REGEX}${JOIN_CHAR}${DATE_REGEX}${JOIN_CHAR}${AUTHOR_REGEX}${JOIN_CHAR}${SIG_REGEX}${JOIN_CHAR}${STAT_REGEX}${JOIN_CHAR}${MSG_REGEX}${JOIN_CHAR}${REFS_REGEX}$"
readonly CWHITE='\033[0;37m'
readonly CGREEN='\033[0;32m'
readonly CYELLOW='\033[0;33m'
readonly CRED='\033[0;31m'
readonly CAQUA='\033[1;36m'
readonly CEND='\033[0m'
readonly CGOOD=$CGREEN
readonly CUNKNOWN=$CYELLOW
readonly CEXPIRED=$CYELLOW
readonly CBAD=$CRED
readonly CNONE=$CWHITE
readonly HASH_COLOR=$CNONE
readonly DATE_COLOR=$CNONE
readonly AUTHOR_COLOR=$CNONE
readonly MSG_COLOR=$CNONE
readonly REF_COLOR=$CAQUA
readonly REF_ERR_MSG="no such ref or file:"
declare -i USE_ANSI_COLOR=1 # (deferred)
declare -i N_COMMITS=12     # (deferred)
declare -i HIDE_MERGED=0    # (deferred)
REF=                        # (deferred)
FILE=                       # (deferred)


## variables ##

declare -a Graphs=()
declare -a Ids=()
declare -a Dates=()
declare -a Authors=()
declare -a Sigs=()
declare -a Stats=()
declare -a Msgs=()
declare -a Refs=()
declare -a SigColors=()
declare -i AuthorW=0
declare -i NCommits=0


## helpers ##

GetUpstreamBranch() # ( local_branch )
{
  local local_branch=$1

  git rev-parse --abbrev-ref $local_branch@{upstream} 2> /dev/null
}

GetCurrentBranch()
{
  git rev-parse --abbrev-ref HEAD
}

DoesBranchExist() # ( branch_name )
{
  local branch_name=$1

  [[ "$branch_name" && "$(git branch --all --list $branch_name)" ]]
}

DoesTagExist() # ( tag_name )
{
  local tag_name=$1

  [[ "$tag_name" && "$(git tag | grep -G "$tag_name$")" ]]
}

DoesCidExist() # ( tag_name )
{
  local commit_id=$1

  [[ "$commit_id" ]] && git rev-parse --verify ${commit_id}^{commit} &> /dev/null
}

ValidateRef() # ( ref ) # where param is a branch_name, tag_name, commit_id
{
  local ref=$1

  DoesBranchExist $ref || DoesTagExist $ref || DoesCidExist $ref || ref=''

  echo $ref ; [[ -n "$ref" ]] ;
}

ValidateParam() # ( ref ) # where param is a branch_name, tag_name, commit_id, or file
{
  local ref=$1

  ref="$( ValidateRef $ref || [[ ! -f "$ref" ]] || echo $ref )"

  echo $ref ; [[ -n "$ref" ]] ;
}

IsAncestor() # ( ref_a ref_b )
{
  local ref_a=$1
  local ref_b=$2

  git merge-base --is-ancestor $ref_a $ref_b
}

Ancestor() # ( ref_a ref_b )
{
  local ref_a=$1
  local ref_b=$2

  git merge-base $ref_a $ref_b
}

JoinChars() # ( "a_spaced_string" )
{
  local a_string="$1"

  echo "${a_string// /$JOIN_CHAR}"
}

FilterJoinChars() # ( an_unspaced_string )
{
  local a_string=$1

  echo $a_string | tr "$JOIN_CHAR" " "
}


## business ##

Init() # ( cli_args* )
{
  local arg
  local valid_param
  local valid_ref
  local is_valid_param
  local is_valid_ref
  local is_file

  # parse cli args
  while getopts 'acn:u' arg
  do    case "${arg}" in
             a) N_COMMITS="$(git rev-list --count HEAD)" ;;
             c) USE_ANSI_COLOR=0                         ;;
             n) N_COMMITS="${OPTARG}"                    ;;
             u) HIDE_MERGED=1                            ;;
             *) echo "Invalid argument: '${arg}'"        ;;
        esac
  done
  shift $(( OPTIND - 1 ))

  # process cli args
  valid_param=$(ValidateParam "$1")
  valid_ref=$(  ValidateRef   "$1")
  is_valid_param=$( [[ -n "$valid_param"                ]] ; echo $((!$?)) ; )
  is_valid_ref=$(   [[ -n "$valid_ref"                  ]] ; echo $((!$?)) ; )
  is_file=$(        (( is_valid_param && ! is_valid_ref )) ; echo $((!$?)) ; )
  REF=$( (( is_file || ! $# )) && echo HEAD         || echo $valid_ref)
  FILE=$((( is_file         )) && echo $valid_param || echo $2        )
  PUB_BRANCH=${PUB_BRANCH:-$(GetUpstreamBranch $(GetCurrentBranch))}

  readonly USE_ANSI_COLOR
  readonly N_COMMITS
  readonly HIDE_MERGED
  readonly REF
  readonly FILE
  readonly PUB_BRANCH


# echo "is_valid_param=$is_valid_param is_valid_ref=$is_valid_ref is_file=$is_file REF=$REF FILE=$FILE" # DEBUG


  (( ! $# )) || (( is_valid_param )) || ! echo "$REF_ERR_MSG $1"
}

CompileResults()
{
  local log_data graph id date author sig stat msg ref

  # reset data
  Graphs=() Ids=() Dates=() Authors=() Sigs=() Stats=() Msgs=() Refs=() SigColors=()

  # compile results
  while read -r log_data
  do    [[ $log_data =~ $LOG_REGEX ]] || continue


# TODO: graph colors and fork/merge node lines
# printf "log_data='%s'\n" "$log_data" ; [[ $log_data =~ $LOG_REGEX ]] && printf "graph='%s'\n" "${BASH_REMATCH[1]}" || printf "graph NFG='%s'\n" "$log_data"


        graph=${BASH_REMATCH[  1]} ; Graphs=( ${Graphs[*]}  $(JoinChars "$graph" )) ;
        id=${BASH_REMATCH[     2]} ; Ids=(    ${Ids[*]}     $(JoinChars "$id"    )) ;
        date=${BASH_REMATCH[   3]} ; Dates=(  ${Dates[*]}   $(JoinChars "$date"  )) ;
        author=${BASH_REMATCH[ 4]} ; Authors=(${Authors[*]} $(JoinChars "$author")) ;
        sig=${BASH_REMATCH[    6]} ; Sigs=(   ${Sigs[*]}    $(JoinChars "$sig"   )) ;
        stat=${BASH_REMATCH[   7]} ; Stats=(  ${Stats[*]}   $(JoinChars "$stat"  )) ;
        msg=${BASH_REMATCH[    8]} ; Msgs=(   ${Msgs[*]}    $(JoinChars "$msg"   )) ;
        ref=${BASH_REMATCH[    9]} ; Refs=(   ${Refs[*]}    $(JoinChars "$ref"   )) ;
        [[ -n "$msg"      ]] ||      Msgs=(   ${Msgs[*]}    "<EMPTY>"             )
        [[ "$stat" == 'E' ]] &&      Sigs=(   ${Sigs[*]}    "<UNKNOWN>"           ) || \
        [[ -n "$sig"      ]] ||      Sigs=(   ${Sigs[*]}    "$JOIN_CHAR"          )
        [[ -n "$ref"      ]] ||      Refs=(   ${Refs[*]}    "$JOIN_CHAR"          )

        (( ${#author} > AuthorW )) && AuthorW=${#author}
  done
}

PrintReport() # ( "header" )
{
  local header="$1"
  local n_results=$(( ${#Ids[*]} ))
  local pad_w=$(( ( -${#header} + ${#Ids[0]} + 1 + ${#Dates[0]} + AuthorW ) / 2 ))
  local pad="$(printf "%${pad_w}s" ' ' | tr ' ' "$HRULE_CHAR")"
  local hrule="|<${pad} ${header} ${pad:$(( pad_w > 0 && ! ( AuthorW % 2 ) ))}>|"

  local result_n graph id date author sig stat msg ref pad
  local has_author_sig sig_color hash_color date_color author_color msg_color ref_color

  # pretty print results
  (( ! HIDE_MERGED )) && echo "${hrule}"
  (( ! n_results   )) && sed 's/[^|]/ /g ; s/^|       /| <None>/' <<<"${hrule}"
  for (( result_n = 0 ; result_n < n_results ; ++result_n ))
  do  graph=${Graphs[$result_n]}
      id=${Ids[$result_n]}
      date=${Dates[$result_n]}
      author=${Authors[$result_n]}
      sig=${Sigs[$result_n]}
      stat=${Stats[$result_n]}
      msg=${Msgs[$result_n]}
      ref=${Refs[$result_n]}
      pad=$(printf "%$(( AuthorW - ${#author} ))s" '')

      if   (( USE_ANSI_COLOR ))
      then has_author_sig=$([[ "$author" == "$sig" ]] ; echo $((!$?)) ;)
           sig_color=$(case "$stat" in
                            'G') echo $CGOOD    ;; # good signature
                            'X') echo $CEXPIRED ;; # good signature that has expired
                            'U') echo $CGOOD    ;; # good signature with unknown trust
                            'E') echo $CUNKNOWN ;; # cannot be checked (e.g. missing key)
                            'B') echo $CBAD     ;; # bad signature
                            'Y') echo $CEXPIRED ;; # good signature made by an expired key
                            'R') echo $CBAD     ;; # good signature made by a revoked key
                            'N') echo $CNONE    ;; # no signature
                       esac)
           hash_color=$HASH_COLOR
           date_color=$DATE_COLOR
           author_color=$((( has_author_sig )) && echo $sig_color || echo $AUTHOR_COLOR)
           msg_color=$MSG_COLOR
           ref_color=$REF_COLOR
      fi

#     printf "$graph_color$(FilterJoinChars $graph) $CEND"
      printf "| $hash_color$id$CEND"
      printf " $date_color$date$CEND"
      printf " $author_color%s$CEND"    "$(FilterJoinChars $author)"
      printf " $pad| $msg_color%s$CEND" "$(FilterJoinChars $msg   )"
      [[ "$ref" != "$JOIN_CHAR" ]] && printf " $ref_color($(FilterJoinChars $ref))$CEND"
      [[ "$sig" != "$JOIN_CHAR" ]] && printf " $sig_color[$(FilterJoinChars $sig)]$CEND"
      printf "\n"
  done

  NCommits=$(( NCommits + ${#Ids[*]} ))
}

Main()
{
  local ancestor=$(Ancestor $PUB_BRANCH $REF)
  local log_cmd header

  if   [[ -z "$PUB_BRANCH" ]]
  then log_cmd="$(printf "$GIT_LOG_CMD_FMT" $N_COMMITS)" header='NO UPSTREAM'
       CompileResults < <($log_cmd              $REF $FILE ; echo ;) ; PrintReport "${header}" ;
  elif ! IsAncestor $PUB_BRANCH $REF && [[ -z ${ancestor:-} ]]
  then log_cmd="$(printf "$GIT_LOG_CMD_FMT" $N_COMMITS)" header='UNRELATED'
       CompileResults < <($log_cmd              $REF $FILE ; echo ;) ; PrintReport ${header} ;
  else log_cmd="$(printf "$GIT_LOG_CMD_FMT" $N_COMMITS)" header='UNMERGED'
       CompileResults < <($log_cmd $ancestor..$REF $FILE ; echo ;) ; PrintReport ${header} ;

       if   (( ! HIDE_MERGED && NCommits < N_COMMITS ))
       then log_cmd="$(printf "$GIT_LOG_CMD_FMT" $(( N_COMMITS - NCommits )))" header='MERGED'
            CompileResults < <($log_cmd $ancestor $FILE  ; echo ;) ; PrintReport ${header} ;
       fi
  fi
}


## main entry ##

Init "$@" && Main
